투표화면 페이지에는 몇가지 페이지를 거쳐야하는 기능이 있습니다.

투표의 상세정보를 확인하고 일정을 결정하고, 투표까지 크게는 총 4가지의 페이지를 거치게 됩니다.
문제사항
이 페이지의 구현을 다른 개발자분에게 부탁드렸고 개발한 결과물은 다음과 같습니다.
<Route path="/vote" element={<VotePage />} /> <Route path="/vote/agree" element={<VoteAgreePage />} /> <Route path="/vote/date" element={<VoteDatePage />} /> <Route path="/vote/success" element={<VoteSuccessPage />} /> <Route path="/vote/fail" element={<VoteFailPage/>} />
이로인해 다음과 같은 문제점이 있었습니다.
- 페이지 간 이동 로직이 복잡해짐
- 연관된 페이지들이 분산되어 있어 유지보수가 어려움
- 상태 관리가 페이지별로 따로 이루어짐
- 전역 상태 관리 필요
해결 방법
Toss 기술 블로그에서 소개된 퍼널(Funnel) 패턴을 참고하여, 여러 단계의 페이지를 하나의 컴포넌트에서 관리할 수 있는 Custom Hook을 구현했습니다.
export const useFunnel = (defaultStep: string) => { //searchParams를 통해 현재 스텝을 관리한다. const [searchParams, setSearchParams] = useSearchParams(); const step = searchParams.get("step") || defaultStep; //Step 컴포넌트는 각 스텝의 컨텐츠를 렌더링한다. //여기서 name을 이용해서 Funnel 컴포넌트에서 현재 스텝을 찾는다. const Step = (props: StepProps): ReactElement => { return <>{props.children}</>; }; //setStep 함수로 url 파라미터를 변경한다. const setStep = (step: string) => { setSearchParams((prev) => { prev.set("step", step); return prev; }); }; //Funnel 컴포넌트는 여러 단계의 Step 컴포넌트 중 현재 활성화된 스텝을 렌더링한다. //find를 통해 Step 중 현재 Step을 찾아 렌더링 const Funnel = ({ children }: FunnelProps) => { const targetStep = children.find((childStep) => childStep.props.name === step); return <>{targetStep}</>; }; return { Funnel, Step, setStep, currentStep: step } as const; };
step이라는 queryString으로 현재 스텝을 관리하고 Step 컴포넌트에 이름을 부여합니다.
setStep으로 현재 렌더링할 step을 지정해줄 수 있고 Funnel에서는 렌더링할 Step 컴포넌트를 찾아 렌더링합니다.
도입
이를 위의 다중 페이지에 적용하였고 다음과 같은 결과를 얻었습니다.
- 7개로 나누어졌던 라우트를 한 라우트로 구조 단순화
{/* 개선 전 */} <Route element={<VoteLayout />}> <Route path="/vote" element={<VotePage />} /> <Route path="/vote/agree" element={<VoteAgreePage />} /> <Route path="/vote/date" element={<VoteDatePage />} /> <Route path="/vote/success" element={<VoteSuccessPage />} /> <Route path="/vote/fail" element={<VoteFailPage/>} /> <Route path="/vote/success/confirm" element={<ConfirmSuccessPage />} /> <Route path="/vote/fail/confirm" element={<ConfirmFailPage />} /> </Route>
{/* 개선 후 */} <Route element={<VoteLayout />}> <Route path="/vote" element={<VotePage />} /> </Route>
- Vote Page 응집도 개선
- 페이지 상태관리를 한 곳에서 하여 데이터 흐름 명확
- 퍼널에서 사용하는 컴포넌트 추상화
- 조건부 렌더링을 담당하는 Step 컴포넌트 생성
const [voteType, setVoteType] = useState<"찬성" | "반대" | null>(null); return ( <S.Container> <Funnel> <Step name="투표메인"> <TravelCard onNext={() => setStep("투표동의")} /> </Step> <Step name="투표동의"> <VoteCard onAgree={() => { setVoteType("찬성"); setStep("날짜지정"); }} onDisagree={() => { setVoteType("반대"); setStep("결과"); // 반대로 바로 이동 }} /> </Step> <Step name="날짜지정"> <VoteDate onNext={() => { setStep("결과"); // 결과(찬성)로 이동 }} /> </Step> {/* ...ETC */}
괜찮아 보이죠? 코드가 전보다 추상화 되어있고 응집도 또한 전보다 많이 높아졌습니다.
다른 문제
하지만 해당 코드도 다음과 같은 문제점이 존재합니다. 이를 코드를 통해 확인해보겠습니다.
//상태가 많아질 경우 사용되는 useState가 많아짐 //퍼널 간 상태관리 시 불필요한 보일러 플레이트 const [voteType, setVoteType] = useState<"찬성" | "반대" | null>(null); return ( <S.Container> <Funnel> <Step name="투표메인"> <TravelCard onNext={() => setStep("투표동의")} /> </Step> <Step name="투표동의"> <VoteCard onAgree={() => { {/* setStep 함수 호출 시 타입 안정성이 떨어짐 */} {/* 잘못된 상태 지정 시에도 강제되지 않음 */} setVoteType("찬성"); setStep("날짜지정"); }} onDisagree={() => { setVoteType("반대"); setStep("결과"); // 반대로 바로 이동 }} /> </Step> <Step name="날짜지정"> <VoteDate onNext={() => { {/* 퍼널 간 상태관리를 놓칠 수 있음 */} setStep("결과"); // 결과(찬성)로 이동 }} /> </Step> {/* ...ETC */}
즉 다음과 같은 문제들이 있었습니다.
- 함수 호출 시 타입 안정성 문제
- 상태관리를 위한 불필요한 보일러 플레이트
- 퍼널 간 이동 시 상태 관리 실수 가능성
개선 방안
그래서 해당 문제들을 해결하기 위해 다음과 같이 해결하고자 합니다
- 함수 호출 시 타입 안정성 문제 → 타입 정의로 타입 안정성 높이기
- 상태관리를 위한 불필요한 보일러 플레이트 → useFunnel 내에서 상태 관리 & 타입 관리
- 퍼널 간 이동 시 상태 관리 실수 가능성 → setStep 내에서 상태 관리 & 타입 지원
이를 해결하기 위해 toss의 useFunnel 라이브러리를 확인해 본 결과, 이 문제들은 당연히 토스 내에서도 인지하고 있었고 토스에서는 다음과 같은 방법으로 해결하였습니다.
두가지 버전이 존재하였습니다
- 과거버전
- 추상화 방법은 현재 방법과 비슷
- 하지만 상태관리를 위한 withState method 사용 시 상태 타입을 강제하지 않음
- setState로 스텝 별 상태관리가 어려움
const KyoboLifeFunnel = () => { const [Funnel, state, setState] = useFunnel(['아파트여부', '지역선택', '완료'] as const).withState<{ propertyType?: '빌라' | '아파트'; address?: string; }>({}); const 상담신청 = useLoanApplicationCallback(); return ( <Funnel> <Funnel.Step name="아파트여부"> <아파트여부스텝 지역선택으로가기={() => setState(prev => ({...prev, step: '지역선택', isApartment: true}))} /> </Funnel.Step> <Funnel.Step name="지역선택"> <지역선택스텝 지역선택완료={(지역정보) => setState(prev => ({...prev, step: '완료', region: 지역정보}))} /> </Funnel.Step> <Funnel.Step name="완료"> <완료스텝 신청={() => 상담신청(state)} /> </Funnel.Step> </Funnel> ); };
- 최신버전
- 강력한 타입 지원
- step 변경 시 직관적인 상태 관리 & 타입 관리
- 하지만 overlay, sub-funnel, history state 관리 등 불필요한 기능
- 렌더 프롭 패턴으로 migrate 시 다른 개발자들의 학습 곡선 고려
const funnel = useFunnel<{ SelectJob: { jobType?: 'STUDENT' | 'EMPLOYEE' }; SelectSchool: { jobType: 'STUDENT'; school?: string }; SelectEmployee: { jobType: 'EMPLOYEE'; company?: string }; EnterJoinDate: { jobType: 'STUDENT'; school: string } | { jobType: 'EMPLOYEE'; company: string }; Confirm: ({ jobType: 'STUDENT'; school: string } | { jobType: 'EMPLOYEE'; company: string }) & { joinDate: string }; }>({ id: 'hello-world', initial: { step: 'SelectJob', context: {}, }, }); return ( <funnel.Render SelectJob={funnel.Render.with({ events: { selectSchool: (_, { history }) => history.push('SelectSchool', { jobType: 'STUDENT' }), selectEmployee: (_, { history }) => history.push('SelectEmployee', { jobType: 'EMPLOYEE' }), }, render({ dispatch }) { return ( <SelectJob onSelectSchool={() => dispatch('selectSchool')} onSelectEmployee={() => dispatch('selectEmployee')} /> ); }, })} SelectSchool={({ history }) => ( <SelectSchool onNext={(school) => history.push('EnterJoinDate', (prev) => ({ ...prev, school, })) } /> )} {/* ...ETC */}
이 두 버전을 고려했을 때, 각 버전의 장점들만 취합하기로 결정했습니다.
- 과거 버전으로 추상화 방법은 가져가면서,
- 최신 버전으로 상태관리, 타입 안정성 높이기
이 내용은 다음 포스트에서 이어 작성하겠습니다.